// Copyright 2017, Paul DeMarco.
// All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

#import "FlutterBluePlusPlugin.h"
#import "Flutterblueplus.pbobjc.h"

@interface CBUUID (CBUUIDAdditionsFlutterBluePlus)
- (NSString *)fullUUIDString;
@end

@implementation CBUUID (CBUUIDAdditionsFlutterBluePlus)
- (NSString *)fullUUIDString
{
    if (self.UUIDString.length == 4)
    {
        return [[NSString stringWithFormat:@"0000%@-0000-1000-8000-00805F9B34FB", self.UUIDString] lowercaseString];
    }
    return [self.UUIDString lowercaseString];
}
@end

typedef NS_ENUM(NSUInteger, LogLevel) {
    emergency = 0,
    alert = 1,
    critical = 2,
    error = 3,
    warning = 4,
    notice = 5,
    info = 6,
    debug = 7
};

@interface FlutterBluePlusPlugin ()
@property(nonatomic, retain) NSObject<FlutterPluginRegistrar> *registrar;
@property(nonatomic, retain) FlutterMethodChannel *channel;
@property(nonatomic, retain) FlutterBluePlusStreamHandler *stateStreamHandler;
@property(nonatomic, retain) CBCentralManager *centralManager;
@property(nonatomic) NSMutableDictionary *scannedPeripherals;
@property(nonatomic) NSMutableArray *servicesThatNeedDiscovered;
@property(nonatomic) NSMutableArray *characteristicsThatNeedDiscovered;
@property(nonatomic) NSMutableDictionary *dataWaitingToWriteWithoutResponse;
@property(nonatomic) LogLevel logLevel;
@end

@implementation FlutterBluePlusPlugin
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar
{
    FlutterMethodChannel *channel = [FlutterMethodChannel methodChannelWithName:NAMESPACE @"/methods"
                                                                binaryMessenger:[registrar messenger]];
    FlutterEventChannel *stateChannel = [FlutterEventChannel eventChannelWithName:NAMESPACE @"/state"
                                                                  binaryMessenger:[registrar messenger]];
    FlutterBluePlusPlugin *instance = [[FlutterBluePlusPlugin alloc] init];
    instance.channel = channel;
    instance.scannedPeripherals = [NSMutableDictionary new];
    instance.servicesThatNeedDiscovered = [NSMutableArray new];
    instance.characteristicsThatNeedDiscovered = [NSMutableArray new];
    instance.dataWaitingToWriteWithoutResponse = [NSMutableDictionary new];
    instance.logLevel = emergency;

    // STATE
    FlutterBluePlusStreamHandler *stateStreamHandler = [[FlutterBluePlusStreamHandler alloc] init];
    [stateChannel setStreamHandler:stateStreamHandler];
    instance.stateStreamHandler = stateStreamHandler;

    [registrar addMethodCallDelegate:instance channel:channel];
}

- (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result
{
    if ([@"setLogLevel" isEqualToString:call.method])
    {
        NSNumber *logLevelIndex = [call arguments];
        _logLevel = (LogLevel)[logLevelIndex integerValue];
        result(nil);
        return;
    }
    if (self.centralManager == nil)
    {
        self.centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:nil options:@{CBCentralManagerOptionShowPowerAlertKey: @(YES)}];
        FlutterStandardTypedData *data = [self toFlutterData:[self toBluetoothStateProto:self->_centralManager.state]];
        self.stateStreamHandler.cachedBluetoothState = data;
    }
    if ([@"state" isEqualToString:call.method])
    {
        FlutterStandardTypedData *data = [self toFlutterData:[self toBluetoothStateProto:self->_centralManager.state]];
        result(data);
    }
    else if ([@"isAvailable" isEqualToString:call.method])
    {
        if (self.centralManager.state != CBManagerStateUnsupported &&
            self.centralManager.state != CBManagerStateUnknown)
        {
            result(@(YES));
        }
        else
        {
            result(@(NO));
        }
    }
    else if ([@"isOn" isEqualToString:call.method])
    {
        if (self.centralManager.state == CBManagerStatePoweredOn)
        {
            result(@(YES));
        }
        else
        {
            result(@(NO));
        }
    }
    else if ([@"name" isEqualToString:call.method])
    {
#if TARGET_OS_IOS
        result([[UIDevice currentDevice] name]);
#else // MacOS
        // TODO: support this via hostname?
        result([FlutterError errorWithCode:@"name"
            message:@"macOS does not support device name"
            details:NULL]);
#endif
    }
    else if ([@"startScan" isEqualToString:call.method])
    {
        // TODO: Request Permission?
        FlutterStandardTypedData *data = [call arguments];
        ProtosScanSettings *request = [[ProtosScanSettings alloc] initWithData:[data data] error:nil];

        // UUID Service filter
        NSArray *uuids = [NSArray array];
        for (int i = 0; i < [request serviceUuidsArray_Count]; i++)
        {
            NSString *u = [request.serviceUuidsArray objectAtIndex:i];
            uuids = [uuids arrayByAddingObject:[CBUUID UUIDWithString:u]];
        }

        NSMutableDictionary<NSString *, id> *scanOpts = [NSMutableDictionary new];
        if (request.allowDuplicates)
        {
            [scanOpts setObject:[NSNumber numberWithBool:YES] forKey:CBCentralManagerScanOptionAllowDuplicatesKey];
        }
        [self->_centralManager scanForPeripheralsWithServices:uuids options:scanOpts];
        result(nil);
    }
    else if ([@"stopScan" isEqualToString:call.method])
    {
        [self->_centralManager stopScan];
        result(nil);
    }
    else if ([@"getConnectedDevices" isEqualToString:call.method])
    {
        // Cannot pass blank UUID list for security reasons. Assume all devices have the Generic Access service 0x1800
        NSArray *periphs =
            [self->_centralManager retrieveConnectedPeripheralsWithServices:@[ [CBUUID UUIDWithString:@"1800"] ]];

        result([self toFlutterData:[self toConnectedDeviceResponseProto:periphs]]);
    }
    else if ([@"connect" isEqualToString:call.method])
    {
        FlutterStandardTypedData *data = [call arguments];
        ProtosConnectRequest *request = [[ProtosConnectRequest alloc] initWithData:[data data] error:nil];
        NSString *remoteId = [request remoteId];
        @try
        {
            CBPeripheral *peripheral = [_scannedPeripherals objectForKey:remoteId];
            if (peripheral == nil)
            {
                NSArray *periphs = [self->_centralManager
                    retrieveConnectedPeripheralsWithServices:@[ [CBUUID UUIDWithString:@"1800"] ]];
                for (CBPeripheral *p in periphs)
                {
                    NSString *uuid = [[p identifier] UUIDString];
                    p.delegate = self;
                    [_scannedPeripherals setObject:p forKey:uuid];
                }
                peripheral = [_scannedPeripherals objectForKey:remoteId];
            }
            if (peripheral == nil)
            {
                @throw [FlutterError errorWithCode:@"connect" message:@"Peripheral not found" details:nil];
            }
            // TODO: Implement Connect options (#36)
            [_centralManager connectPeripheral:peripheral options:nil];
            result(nil);
        }
        @catch (NSException *e)
        {
            result(e);
        }
    }
    else if ([@"disconnect" isEqualToString:call.method])
    {
        NSString *remoteId = [call arguments];
        @try
        {
            CBPeripheral *peripheral = [self findPeripheral:remoteId];
            [_centralManager cancelPeripheralConnection:peripheral];
            result(nil);
        }
        @catch (NSException *e)
        {
            result(e);
        }
    }
    else if ([@"deviceState" isEqualToString:call.method])
    {
        NSString *remoteId = [call arguments];
        @try
        {
            CBPeripheral *peripheral = [self findPeripheral:remoteId];
            result([self toFlutterData:[self toDeviceStateProto:peripheral state:peripheral.state]]);
        }
        @catch (NSException *e)
        {
            result(e);
        }
    }
    else if ([@"discoverServices" isEqualToString:call.method])
    {
        NSString *remoteId = [call arguments];
        @try
        {
            CBPeripheral *peripheral = [self findPeripheral:remoteId];
            // Clear helper arrays
            [_servicesThatNeedDiscovered removeAllObjects];
            [_characteristicsThatNeedDiscovered removeAllObjects];
            [peripheral discoverServices:nil];
            result(nil);
        }
        @catch (NSException *e)
        {
            result(e);
        }
    }
    else if ([@"services" isEqualToString:call.method])
    {
        NSString *remoteId = [call arguments];
        @try
        {
            CBPeripheral *peripheral = [self findPeripheral:remoteId];
            result([self toFlutterData:[self toServicesResultProto:peripheral]]);
        }
        @catch (NSException *e)
        {
            result(e);
        }
    }
    else if ([@"readCharacteristic" isEqualToString:call.method])
    {
        FlutterStandardTypedData *data = [call arguments];
        ProtosReadCharacteristicRequest *request = [[ProtosReadCharacteristicRequest alloc] initWithData:[data data]
                                                                                                   error:nil];
        NSString *remoteId = [request remoteId];
        @try
        {
            // Find peripheral
            CBPeripheral *peripheral = [self findPeripheral:remoteId];

            // Find characteristic
            CBCharacteristic *characteristic = [self locateCharacteristic:[request characteristicUuid]
                                                               peripheral:peripheral
                                                                serviceId:[request serviceUuid]
                                                       secondaryServiceId:[request secondaryServiceUuid]];

            // Trigger a read
            [peripheral readValueForCharacteristic:characteristic];
            result(nil);
        }
        @catch (NSException *e)
        {
            result(e);
        }
    }
    else if ([@"readDescriptor" isEqualToString:call.method])
    {
        FlutterStandardTypedData *data = [call arguments];
        ProtosReadDescriptorRequest *request = [[ProtosReadDescriptorRequest alloc] initWithData:[data data] error:nil];
        NSString *remoteId = [request remoteId];
        @try
        {
            // Find peripheral
            CBPeripheral *peripheral = [self findPeripheral:remoteId];

            // Find characteristic
            CBCharacteristic *characteristic = [self locateCharacteristic:[request characteristicUuid]
                                                               peripheral:peripheral
                                                                serviceId:[request serviceUuid]
                                                       secondaryServiceId:[request secondaryServiceUuid]];

            // Find descriptor
            CBDescriptor *descriptor = [self locateDescriptor:[request descriptorUuid] characteristic:characteristic];
            [peripheral readValueForDescriptor:descriptor];
            result(nil);
        }
        @catch (NSException *e)
        {
            result(e);
        }
    }
    else if ([@"writeCharacteristic" isEqualToString:call.method])
    {
        FlutterStandardTypedData *data = [call arguments];
        ProtosWriteCharacteristicRequest *request = [[ProtosWriteCharacteristicRequest alloc] initWithData:[data data]
                                                                                                     error:nil];
        NSString *remoteId = [request remoteId];
        @try
        {
            // Find peripheral
            CBPeripheral *peripheral = [self findPeripheral:remoteId];

            // Get correct write type
            CBCharacteristicWriteType type =
                ([request writeType] == ProtosWriteCharacteristicRequest_WriteType_WithoutResponse)
                    ? CBCharacteristicWriteWithoutResponse
                    : CBCharacteristicWriteWithResponse;

            if (type == CBCharacteristicWriteWithoutResponse && !peripheral.canSendWriteWithoutResponse) {
                // canSendWriteWithoutResponse represents the current readiness of the peripheral to accept
                // more write requests. If the peripheral isn't ready, we queue the request for later.
                [_dataWaitingToWriteWithoutResponse setObject:request forKey:remoteId];
                result(nil);
            } else {
                // Find characteristic
                CBCharacteristic *characteristic = [self locateCharacteristic:[request characteristicUuid]
                                                                   peripheral:peripheral
                                                                    serviceId:[request serviceUuid]
                                                           secondaryServiceId:[request secondaryServiceUuid]];
                // Write to characteristic
                [peripheral writeValue:[request value] forCharacteristic:characteristic type:type];
                result(@(YES));
            }
        }
        @catch (NSException *e)
        {
            result(e);
        }
    }
    else if ([@"writeDescriptor" isEqualToString:call.method])
    {
        FlutterStandardTypedData *data = [call arguments];
        ProtosWriteDescriptorRequest *request = [[ProtosWriteDescriptorRequest alloc] initWithData:[data data]
                                                                                             error:nil];
        NSString *remoteId = [request remoteId];
        @try
        {
            // Find peripheral
            CBPeripheral *peripheral = [self findPeripheral:remoteId];

            // Find characteristic
            CBCharacteristic *characteristic = [self locateCharacteristic:[request characteristicUuid]
                                                               peripheral:peripheral
                                                                serviceId:[request serviceUuid]
                                                       secondaryServiceId:[request secondaryServiceUuid]];

            // Find descriptor
            CBDescriptor *descriptor = [self locateDescriptor:[request descriptorUuid] characteristic:characteristic];

            // Write descriptor
            [peripheral writeValue:[request value] forDescriptor:descriptor];
            result(nil);
        }
        @catch (NSException *e)
        {
            result(e);
        }
    }
    else if ([@"setNotification" isEqualToString:call.method])
    {
        FlutterStandardTypedData *data = [call arguments];
        ProtosSetNotificationRequest *request = [[ProtosSetNotificationRequest alloc] initWithData:[data data]
                                                                                             error:nil];
        NSString *remoteId = [request remoteId];
        @try
        {
            // Find peripheral
            CBPeripheral *peripheral = [self findPeripheral:remoteId];

            // Find characteristic
            CBCharacteristic *characteristic = [self locateCharacteristic:[request characteristicUuid]
                                                               peripheral:peripheral
                                                                serviceId:[request serviceUuid]
                                                       secondaryServiceId:[request secondaryServiceUuid]];

            // Set notification value
            [peripheral setNotifyValue:[request enable] forCharacteristic:characteristic];
            result(nil);
        }
        @catch (NSException *e)
        {
            result(e);
        }
    }
    else if ([@"mtu" isEqualToString:call.method])
    {
        NSString *remoteId = [call arguments];
        @try
        {
            CBPeripheral *peripheral = [self findPeripheral:remoteId];
            uint32_t mtu = [self getMtu:peripheral];
            result([self toFlutterData:[self toMtuSizeResponseProto:peripheral mtu:mtu]]);
        }
        @catch (NSException *e)
        {
            result(e);
        }
    }
    else if ([@"requestMtu" isEqualToString:call.method])
    {
        result([FlutterError errorWithCode:@"requestMtu"
                                   message:@"iOS does not allow mtu requests to the peripheral"
                                   details:NULL]);
    }
    else if ([@"readRssi" isEqualToString:call.method])
    {
        NSString *remoteId = [call arguments];
        @try
        {
            CBPeripheral *peripheral = [self findPeripheral:remoteId];
            [peripheral readRSSI];
            result(nil);
        }
        @catch (NSException *e)
        {
            result(e);
        }
    }
    else if([@"requestConnectionPriority" isEqualToString:call.method])
    {
        result([FlutterError errorWithCode:@"requestConnectionPriority" 
                                   message:@"iOS does not support connection priority requests"
                                   details:NULL]);
    }
    else if([@"setPreferredPhy" isEqualToString:call.method])
    {
        result([FlutterError errorWithCode:@"setPreferredPhy" 
                                   message:@"iOS does not support set preferred phy requests"
                                   details:NULL]);
    }
    else if([@"removeBond" isEqualToString:call.method])
    {
        result([FlutterError errorWithCode:@"removeBond" 
                                message:@"plugin does not support removeBond function on iOS"
                                details:NULL]);
    }
    else
    {
        result(FlutterMethodNotImplemented);
    }
}

- (CBPeripheral *)findPeripheral:(NSString *)remoteId
{
    NSArray<CBPeripheral *> *peripherals =
        [_centralManager retrievePeripheralsWithIdentifiers:@[ [[NSUUID alloc] initWithUUIDString:remoteId] ]];
    CBPeripheral *peripheral;
    for (CBPeripheral *p in peripherals)
    {
        if ([[p.identifier UUIDString] isEqualToString:remoteId])
        {
            peripheral = p;
            break;
        }
    }
    if (peripheral == nil)
    {
        @throw [FlutterError errorWithCode:@"findPeripheral" message:@"Peripheral not found" details:nil];
    }
    return peripheral;
}

- (CBCharacteristic *)locateCharacteristic:(NSString *)characteristicId
                                peripheral:(CBPeripheral *)peripheral
                                 serviceId:(NSString *)serviceId
                        secondaryServiceId:(NSString *)secondaryServiceId
{
    CBService *primaryService = [self getServiceFromArray:serviceId array:[peripheral services]];
    if (primaryService == nil || [primaryService isPrimary] == false)
    {
        @throw [FlutterError errorWithCode:@"locateCharacteristic"
                                   message:@"service could not be located on the device"
                                   details:nil];
    }

    CBService *secondaryService;
    if (secondaryServiceId.length)
    {
        secondaryService = [self getServiceFromArray:secondaryServiceId array:[primaryService includedServices]];
        @throw [FlutterError errorWithCode:@"locateCharacteristic"
                                   message:@"secondary service could not be located on the device"
                                   details:secondaryServiceId];
    }

    CBService *service = (secondaryService != nil) ? secondaryService : primaryService;

    CBCharacteristic *characteristic = [self getCharacteristicFromArray:characteristicId
                                                                  array:[service characteristics]];
    if (characteristic == nil)
    {
        @throw [FlutterError errorWithCode:@"locateCharacteristic"
                                   message:@"characteristic could not be located on the device"
                                   details:nil];
    }
    return characteristic;
}

- (CBDescriptor *)locateDescriptor:(NSString *)descriptorId characteristic:(CBCharacteristic *)characteristic
{
    CBDescriptor *descriptor = [self getDescriptorFromArray:descriptorId array:[characteristic descriptors]];
    if (descriptor == nil)
    {
        @throw [FlutterError errorWithCode:@"locateDescriptor"
                                   message:@"descriptor could not be located on the device"
                                   details:nil];
    }
    return descriptor;
}

// Reverse search to find primary service
- (CBService *)findPrimaryService:(CBService *)secondaryService peripheral:(CBPeripheral *)peripheral
{
    for (CBService *s in [peripheral services])
    {
        for (CBService *ss in [s includedServices])
        {
            if ([[ss.UUID UUIDString] isEqualToString:[secondaryService.UUID UUIDString]])
            {
                return s;
            }
        }
    }
    return nil;
}

- (CBDescriptor *)findCCCDescriptor:(CBCharacteristic *)characteristic
{
    for (CBDescriptor *d in characteristic.descriptors)
    {
        if ([d.UUID.UUIDString isEqualToString:@"2902"])
        {
            return d;
        }
    }
    return nil;
}

- (CBService *)getServiceFromArray:(NSString *)uuidString array:(NSArray<CBService *> *)array
{
    for (CBService *s in array)
    {
        if ([[s UUID] isEqual:[CBUUID UUIDWithString:uuidString]])
        {
            return s;
        }
    }
    return nil;
}

- (CBCharacteristic *)getCharacteristicFromArray:(NSString *)uuidString array:(NSArray<CBCharacteristic *> *)array
{
    for (CBCharacteristic *c in array)
    {
        if ([[c UUID] isEqual:[CBUUID UUIDWithString:uuidString]])
        {
            return c;
        }
    }
    return nil;
}

- (CBDescriptor *)getDescriptorFromArray:(NSString *)uuidString array:(NSArray<CBDescriptor *> *)array
{
    for (CBDescriptor *d in array)
    {
        if ([[d UUID] isEqual:[CBUUID UUIDWithString:uuidString]])
        {
            return d;
        }
    }
    return nil;
}

//
// CBCentralManagerDelegate methods
//
- (void)centralManagerDidUpdateState:(nonnull CBCentralManager *)central
{
    FlutterStandardTypedData *data = [self toFlutterData:[self toBluetoothStateProto:self->_centralManager.state]];
    if (_stateStreamHandler.sink != nil)
    {
        self.stateStreamHandler.sink(data);
    }
    else
    {
        self.stateStreamHandler.cachedBluetoothState = data;
    }
}

- (void)centralManager:(CBCentralManager *)central
    didDiscoverPeripheral:(CBPeripheral *)peripheral
        advertisementData:(NSDictionary<NSString *, id> *)advertisementData
                     RSSI:(NSNumber *)RSSI
{
    [self.scannedPeripherals setObject:peripheral forKey:[[peripheral identifier] UUIDString]];
    ProtosScanResult *result = [self toScanResultProto:peripheral advertisementData:advertisementData RSSI:RSSI];
    [_channel invokeMethod:@"ScanResult" arguments:[self toFlutterData:result]];
}

- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral
{
    if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didConnectPeripheral");
    }

    // Register self as delegate for peripheral
    peripheral.delegate = self;

    // Send initial mtu size
    uint32_t mtu = [self getMtu:peripheral];
    [_channel invokeMethod:@"MtuSize" arguments:[self toFlutterData:[self toMtuSizeResponseProto:peripheral mtu:mtu]]];

    // Send connection state
    [_channel invokeMethod:@"DeviceState"
                 arguments:[self toFlutterData:[self toDeviceStateProto:peripheral state:peripheral.state]]];
}

- (void)centralManager:(CBCentralManager *)central
    didDisconnectPeripheral:(CBPeripheral *)peripheral
                      error:(NSError *)error
{
    if (error) {
        NSLog(@"[FBP-iOS] didDisconnectPeripheral: [Error] %@", [error localizedDescription]);
    } else if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didDisconnectPeripheral");
    }

    // Unregister self as delegate for peripheral, not working #42
    peripheral.delegate = nil;

    // Send connection state
    [_channel invokeMethod:@"DeviceState"
                 arguments:[self toFlutterData:[self toDeviceStateProto:peripheral state:peripheral.state]]];
}

- (void)centralManager:(CBCentralManager *)central
    didFailToConnectPeripheral:(CBPeripheral *)peripheral
                         error:(NSError *)error
{
    if (error) {
        NSLog(@"[FBP-iOS] didFailToConnectPeripheral: [Error] %@", [error localizedDescription]);
    } else if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didFailToConnectPeripheral");
    }

    // Send connection state
    [_channel invokeMethod:@"DeviceState"
                 arguments:[self toFlutterData:[self toDeviceStateProto:peripheral state:peripheral.state]]];
}

//
// CBPeripheralDelegate methods
//
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(NSError *)error
{
    if (error) {
        NSLog(@"[FBP-iOS] didDiscoverServices: [Error] %@", [error localizedDescription]);
    } else if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didDiscoverServices");
    }

    // Send negotiated mtu size
    uint32_t mtu = [self getMtu:peripheral];
    [_channel invokeMethod:@"MtuSize" arguments:[self toFlutterData:[self toMtuSizeResponseProto:peripheral mtu:mtu]]];

    // Loop through and discover characteristics and secondary services
    [_servicesThatNeedDiscovered addObjectsFromArray:peripheral.services];
    for (CBService *s in [peripheral services])
    {
        NSLog(@"[FBP-iOS] Found service: %@", [s.UUID UUIDString]);
        [peripheral discoverCharacteristics:nil forService:s];
        // [peripheral discoverIncludedServices:nil forService:s]; // Secondary services in the future (#8)
    }
}

- (void)peripheral:(CBPeripheral *)peripheral
    didDiscoverCharacteristicsForService:(CBService *)service
                                   error:(NSError *)error
{
    if (error) {
        NSLog(@"[FBP-iOS] didDiscoverCharacteristicsForService: [Error] %@", [error localizedDescription]);
    } else if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didDiscoverCharacteristicsForService");
    }

    // Loop through and discover descriptors for characteristics
    [_servicesThatNeedDiscovered removeObject:service];
    [_characteristicsThatNeedDiscovered addObjectsFromArray:service.characteristics];
    for (CBCharacteristic *c in [service characteristics])
    {
        [peripheral discoverDescriptorsForCharacteristic:c];
    }
}

- (void)peripheral:(CBPeripheral *)peripheral
    didDiscoverDescriptorsForCharacteristic:(CBCharacteristic *)characteristic
                                      error:(NSError *)error
{
    if (error) {
        NSLog(@"[FBP-iOS] didDiscoverDescriptorsForCharacteristic: [Error] %@", [error localizedDescription]);
    } else if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didDiscoverDescriptorsForCharacteristic");
    }

    [_characteristicsThatNeedDiscovered removeObject:characteristic];
    if (_servicesThatNeedDiscovered.count > 0 || _characteristicsThatNeedDiscovered.count > 0)
    {
        // Still discovering
        return;
    }

    // Send updated tree
    ProtosDiscoverServicesResult *result = [self toServicesResultProto:peripheral];

    [_channel invokeMethod:@"DiscoverServicesResult" arguments:[self toFlutterData:result]];
}

- (void)peripheral:(CBPeripheral *)peripheral
    didDiscoverIncludedServicesForService:(CBService *)service
                                    error:(NSError *)error
{
    if (error) {
        NSLog(@"[FBP-iOS] didDiscoverIncludedServicesForService: [Error] %@", [error localizedDescription]);
    } else if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didDiscoverIncludedServicesForService");
    }

    // Loop through and discover characteristics for secondary services
    for (CBService *ss in [service includedServices])
    {
        [peripheral discoverCharacteristics:nil forService:ss];
    }
}

- (void)peripheral:(CBPeripheral *)peripheral
    didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic
                              error:(NSError *)error
{
    if (error) {
        NSLog(@"[FBP-iOS] didUpdateValueForCharacteristic: [Error] %@", [error localizedDescription]);
    } else if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didUpdateValueForCharacteristic %@", [peripheral.identifier UUIDString]);
    }

    ProtosReadCharacteristicResponse *result = [[ProtosReadCharacteristicResponse alloc] init];
    [result setRemoteId:[peripheral.identifier UUIDString]];
    [result setCharacteristic:[self toCharacteristicProto:peripheral characteristic:characteristic]];

    [_channel invokeMethod:@"ReadCharacteristicResponse" arguments:[self toFlutterData:result]];

    // on iOS, this method also handles notification values
    ProtosOnCharacteristicChanged *onChangedResult = [[ProtosOnCharacteristicChanged alloc] init];
    [onChangedResult setRemoteId:[peripheral.identifier UUIDString]];
    [onChangedResult setCharacteristic:[self toCharacteristicProto:peripheral characteristic:characteristic]];

    [_channel invokeMethod:@"OnCharacteristicChanged" arguments:[self toFlutterData:onChangedResult]];
}

- (void)peripheral:(CBPeripheral *)peripheral
    didWriteValueForCharacteristic:(CBCharacteristic *)characteristic
                             error:(NSError *)error
{
    if (error) {
        NSLog(@"[FBP-iOS] didWriteValueForCharacteristic: [Error] %@", [error localizedDescription]);
    } else if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didWriteValueForCharacteristic");
    }

    ProtosWriteCharacteristicRequest *request = [[ProtosWriteCharacteristicRequest alloc] init];
    [request setRemoteId:[peripheral.identifier UUIDString]];
    [request setCharacteristicUuid:[characteristic.UUID fullUUIDString]];
    [request setServiceUuid:[characteristic.service.UUID fullUUIDString]];

    ProtosWriteCharacteristicResponse *result = [[ProtosWriteCharacteristicResponse alloc] init];
    [result setRequest:request];
    [result setSuccess:(error == nil)];

    [_channel invokeMethod:@"WriteCharacteristicResponse" arguments:[self toFlutterData:result]];
}

- (void)peripheral:(CBPeripheral *)peripheral
    didUpdateNotificationStateForCharacteristic:(CBCharacteristic *)characteristic
                                          error:(NSError *)error
{
    if (error) {
        NSLog(@"[FBP-iOS] didUpdateNotificationStateForCharacteristic: [Error] %@", [error localizedDescription]);
    } else if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didUpdateNotificationStateForCharacteristic");
    }

    // Read CCC descriptor of characteristic
    CBDescriptor *cccd = [self findCCCDescriptor:characteristic];
    if (cccd == nil || error != nil)
    {
        // Send error
        ProtosSetNotificationResponse *response = [[ProtosSetNotificationResponse alloc] init];
        [response setRemoteId:[peripheral.identifier UUIDString]];
        [response setCharacteristic:[self toCharacteristicProto:peripheral characteristic:characteristic]];
        [response setSuccess:false];
        [_channel invokeMethod:@"SetNotificationResponse" arguments:[self toFlutterData:response]];
        return;
    }

    // Request a read
    [peripheral readValueForDescriptor:cccd];
}

- (void)peripheral:(CBPeripheral *)peripheral
    didUpdateValueForDescriptor:(CBDescriptor *)descriptor
                          error:(NSError *)error
{
    if (error) {
        NSLog(@"[FBP-iOS] didUpdateValueForDescriptor: [Error] %@", [error localizedDescription]);
    } else if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didUpdateValueForDescriptor");
    }

    ProtosReadDescriptorRequest *q = [[ProtosReadDescriptorRequest alloc] init];
    [q setRemoteId:[peripheral.identifier UUIDString]];
    [q setCharacteristicUuid:[descriptor.characteristic.UUID fullUUIDString]];
    [q setDescriptorUuid:[descriptor.UUID fullUUIDString]];
    if ([descriptor.characteristic.service isPrimary])
    {
        [q setServiceUuid:[descriptor.characteristic.service.UUID fullUUIDString]];
    }
    else
    {
        [q setSecondaryServiceUuid:[descriptor.characteristic.service.UUID fullUUIDString]];
        CBService *primaryService = [self findPrimaryService:[descriptor.characteristic service]
                                                  peripheral:[descriptor.characteristic.service peripheral]];
        [q setServiceUuid:[primaryService.UUID fullUUIDString]];
    }

    ProtosReadDescriptorResponse *result = [[ProtosReadDescriptorResponse alloc] init];
    [result setRequest:q];

    int value = [descriptor.value intValue];
    [result setValue:[NSData dataWithBytes:&value length:sizeof(value)]];

    [_channel invokeMethod:@"ReadDescriptorResponse" arguments:[self toFlutterData:result]];

    // If descriptor is CCCD, send a SetNotificationResponse in case anything is awaiting
    if ([descriptor.UUID.UUIDString isEqualToString:@"2902"])
    {
        ProtosSetNotificationResponse *response = [[ProtosSetNotificationResponse alloc] init];
        [response setRemoteId:[peripheral.identifier UUIDString]];
        [response setCharacteristic:[self toCharacteristicProto:peripheral characteristic:descriptor.characteristic]];
        [response setSuccess:true];

        [_channel invokeMethod:@"SetNotificationResponse" arguments:[self toFlutterData:response]];
    }
}

- (void)peripheral:(CBPeripheral *)peripheral
    didWriteValueForDescriptor:(CBDescriptor *)descriptor
                         error:(NSError *)error
{
    if (error) {
        NSLog(@"[FBP-iOS] didWriteValueForDescriptor: [Error] %@", [error localizedDescription]);
    } else if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didWriteValueForDescriptor");
    }

    ProtosWriteDescriptorRequest *request = [[ProtosWriteDescriptorRequest alloc] init];
    [request setRemoteId:[peripheral.identifier UUIDString]];
    [request setCharacteristicUuid:[descriptor.characteristic.UUID fullUUIDString]];
    [request setDescriptorUuid:[descriptor.UUID fullUUIDString]];
    if ([descriptor.characteristic.service isPrimary])
    {
        [request setServiceUuid:[descriptor.characteristic.service.UUID fullUUIDString]];
    }
    else
    {
        [request setSecondaryServiceUuid:[descriptor.characteristic.service.UUID fullUUIDString]];
        CBService *primaryService = [self findPrimaryService:[descriptor.characteristic service]
                                                  peripheral:[descriptor.characteristic.service peripheral]];
        [request setServiceUuid:[primaryService.UUID fullUUIDString]];
    }

    ProtosWriteDescriptorResponse *result = [[ProtosWriteDescriptorResponse alloc] init];
    [result setRequest:request];
    [result setSuccess:(error == nil)];

    [_channel invokeMethod:@"WriteDescriptorResponse" arguments:[self toFlutterData:result]];
}

- (void)peripheral:(CBPeripheral *)peripheral didReadRSSI:(NSNumber *)rssi error:(NSError *)error
{
    if (error) {
        NSLog(@"[FBP-iOS] didReadRSSI: [Error] %@", [error localizedDescription]);
    } else if (_logLevel >= debug) {
        NSLog(@"[FBP-iOS] didReadRSSI");
    }

    ProtosReadRssiResult *result = [[ProtosReadRssiResult alloc] init];
    [result setRemoteId:[peripheral.identifier UUIDString]];
    [result setRssi:[rssi intValue]];

    [_channel invokeMethod:@"ReadRssiResult" arguments:[self toFlutterData:result]];
}

- (void)peripheralIsReadyToSendWriteWithoutResponse:(CBPeripheral *)peripheral
{
    ProtosWriteCharacteristicRequest *request =
        [_dataWaitingToWriteWithoutResponse objectForKey:[[peripheral identifier] UUIDString]];
    if (request != nil)
    {
        // Find characteristic
        CBCharacteristic *characteristic = [self locateCharacteristic:[request characteristicUuid]
                                                           peripheral:peripheral
                                                            serviceId:[request serviceUuid]
                                                   secondaryServiceId:[request secondaryServiceUuid]];
        // Write to characteristic
        [peripheral writeValue:[request value]
             forCharacteristic:characteristic
                          type:CBCharacteristicWriteWithoutResponse];
        ProtosWriteCharacteristicResponse *result = [[ProtosWriteCharacteristicResponse alloc] init];
        [result setRequest:request];
        [result setSuccess:true];

        [_channel invokeMethod:@"WriteCharacteristicResponse" arguments:[self toFlutterData:result]];
        [_dataWaitingToWriteWithoutResponse removeObjectForKey:[[peripheral identifier] UUIDString]];
    }
}

//
// Proto Helper methods
//
- (FlutterStandardTypedData *)toFlutterData:(GPBMessage *)proto
{
    FlutterStandardTypedData *data = [FlutterStandardTypedData typedDataWithBytes:[[proto data] copy]];
    return data;
}

- (ProtosBluetoothState *)toBluetoothStateProto:(CBManagerState)state
{
    ProtosBluetoothState *result = [[ProtosBluetoothState alloc] init];
    switch (state)
    {
    case CBManagerStateResetting:
        [result setState:ProtosBluetoothState_State_TurningOn];
        break;
    case CBManagerStateUnsupported:
        [result setState:ProtosBluetoothState_State_Unavailable];
        break;
    case CBManagerStateUnauthorized:
        [result setState:ProtosBluetoothState_State_Unauthorized];
        break;
    case CBManagerStatePoweredOff:
        [result setState:ProtosBluetoothState_State_Off];
        break;
    case CBManagerStatePoweredOn:
        [result setState:ProtosBluetoothState_State_On];
        break;
    default:
        [result setState:ProtosBluetoothState_State_Unknown];
        break;
    }
    return result;
}

- (ProtosScanResult *)toScanResultProto:(CBPeripheral *)peripheral
                      advertisementData:(NSDictionary<NSString *, id> *)advertisementData
                                   RSSI:(NSNumber *)RSSI
{
    ProtosScanResult *result = [[ProtosScanResult alloc] init];
    [result setDevice:[self toDeviceProto:peripheral]];
    [result setRssi:[RSSI intValue]];

    ProtosAdvertisementData *ads = [[ProtosAdvertisementData alloc] init];
    [ads setConnectable:[advertisementData[CBAdvertisementDataIsConnectable] boolValue]];
    [ads setLocalName:advertisementData[CBAdvertisementDataLocalNameKey]];

    // Tx Power Level
    NSNumber *txPower = advertisementData[CBAdvertisementDataTxPowerLevelKey];
    if (txPower != nil)
    {
        ProtosInt32Value *txPowerWrapper = [[ProtosInt32Value alloc] init];
        [txPowerWrapper setValue:[txPower intValue]];
        [ads setTxPowerLevel:txPowerWrapper];
    }

    // Manufacturer Specific Data
    NSData *manufData = advertisementData[CBAdvertisementDataManufacturerDataKey];
    if (manufData != nil && manufData.length > 2)
    {
        unsigned short manufacturerId;
        [manufData getBytes:&manufacturerId length:2];
        [[ads manufacturerData] setObject:[manufData subdataWithRange:NSMakeRange(2, manufData.length - 2)]
                                   forKey:manufacturerId];
    }

    // Service Data
    NSDictionary *serviceData = advertisementData[CBAdvertisementDataServiceDataKey];
    if (serviceData != nil)
    {
        for (CBUUID *uuid in serviceData)
        {
            [[ads serviceData] setObject:serviceData[uuid] forKey:uuid.UUIDString];
        }
    }
    
    // Service Uuids
    NSArray *serviceUuids = advertisementData[CBAdvertisementDataServiceUUIDsKey];
    if (serviceUuids != nil)
    {
        for (CBUUID *uuid in serviceUuids)
        {
            [[ads serviceUuidsArray] addObject:uuid.UUIDString];
        }
    }
    [result setAdvertisementData:ads];
    return result;
}

- (ProtosBluetoothDevice *)toDeviceProto:(CBPeripheral *)peripheral
{
    ProtosBluetoothDevice *result = [[ProtosBluetoothDevice alloc] init];
    [result setName:[peripheral name]];
    [result setRemoteId:[[peripheral identifier] UUIDString]];
    [result setType:ProtosBluetoothDevice_Type_Le]; // TODO: Does iOS differentiate?
    return result;
}

- (ProtosDeviceStateResponse *)toDeviceStateProto:(CBPeripheral *)peripheral state:(CBPeripheralState)state
{
    ProtosDeviceStateResponse *result = [[ProtosDeviceStateResponse alloc] init];
    switch (state)
    {
    case CBPeripheralStateDisconnected:
        [result setState:ProtosDeviceStateResponse_BluetoothDeviceState_Disconnected];
        break;
    case CBPeripheralStateConnecting:
        [result setState:ProtosDeviceStateResponse_BluetoothDeviceState_Connecting];
        break;
    case CBPeripheralStateConnected:
        [result setState:ProtosDeviceStateResponse_BluetoothDeviceState_Connected];
        break;
    case CBPeripheralStateDisconnecting:
        [result setState:ProtosDeviceStateResponse_BluetoothDeviceState_Disconnecting];
        break;
    }
    [result setRemoteId:[[peripheral identifier] UUIDString]];
    return result;
}

- (ProtosDiscoverServicesResult *)toServicesResultProto:(CBPeripheral *)peripheral
{
    ProtosDiscoverServicesResult *result = [[ProtosDiscoverServicesResult alloc] init];
    [result setRemoteId:[peripheral.identifier UUIDString]];
    NSMutableArray *servicesProtos = [NSMutableArray new];
    for (CBService *s in [peripheral services])
    {
        [servicesProtos addObject:[self toServiceProto:peripheral service:s]];
    }
    [result setServicesArray:servicesProtos];
    return result;
}

- (ProtosConnectedDevicesResponse *)toConnectedDeviceResponseProto:(NSArray<CBPeripheral *> *)periphs
{
    ProtosConnectedDevicesResponse *result = [[ProtosConnectedDevicesResponse alloc] init];
    NSMutableArray *deviceProtos = [NSMutableArray new];
    for (CBPeripheral *p in periphs)
    {
        [deviceProtos addObject:[self toDeviceProto:p]];
    }
    [result setDevicesArray:deviceProtos];
    return result;
}

- (ProtosBluetoothService *)toServiceProto:(CBPeripheral *)peripheral service:(CBService *)service
{
    ProtosBluetoothService *result = [[ProtosBluetoothService alloc] init];
    [result setRemoteId:[peripheral.identifier UUIDString]];
    [result setUuid:[service.UUID fullUUIDString]];
    [result setIsPrimary:[service isPrimary]];

    // Characteristic Array
    NSMutableArray *characteristicProtos = [NSMutableArray new];
    for (CBCharacteristic *c in [service characteristics])
    {
        [characteristicProtos addObject:[self toCharacteristicProto:peripheral characteristic:c]];
    }
    [result setCharacteristicsArray:characteristicProtos];

    // Included Services Array
    NSMutableArray *includedServicesProtos = [NSMutableArray new];
    for (CBService *s in [service includedServices])
    {
        [includedServicesProtos addObject:[self toServiceProto:peripheral service:s]];
    }
    [result setIncludedServicesArray:includedServicesProtos];

    return result;
}

- (ProtosBluetoothCharacteristic *)toCharacteristicProto:(CBPeripheral *)peripheral
                                          characteristic:(CBCharacteristic *)characteristic
{
    ProtosBluetoothCharacteristic *result = [[ProtosBluetoothCharacteristic alloc] init];
    [result setUuid:[characteristic.UUID fullUUIDString]];
    [result setRemoteId:[peripheral.identifier UUIDString]];
    [result setProperties:[self toCharacteristicPropsProto:characteristic.properties]];
    [result setValue:[characteristic value]];

    NSMutableArray *descriptorProtos = [NSMutableArray new];
    for (CBDescriptor *d in [characteristic descriptors])
    {
        [descriptorProtos addObject:[self toDescriptorProto:peripheral descriptor:d]];
    }

    [result setDescriptorsArray:descriptorProtos];
    
    if ([characteristic.service isPrimary])
    {
        [result setServiceUuid:[characteristic.service.UUID fullUUIDString]];
    }
    else
    {
        // Reverse search to find service and secondary service UUID
        [result setSecondaryServiceUuid:[characteristic.service.UUID fullUUIDString]];
        CBService *primaryService = [self findPrimaryService:[characteristic service]
                                                  peripheral:[characteristic.service peripheral]];
        [result setServiceUuid:[primaryService.UUID fullUUIDString]];
    }
    return result;
}

- (ProtosBluetoothDescriptor *)toDescriptorProto:(CBPeripheral *)peripheral descriptor:(CBDescriptor *)descriptor
{
    ProtosBluetoothDescriptor *result = [[ProtosBluetoothDescriptor alloc] init];
    [result setUuid:[descriptor.UUID fullUUIDString]];
    [result setRemoteId:[peripheral.identifier UUIDString]];
    [result setCharacteristicUuid:[descriptor.characteristic.UUID fullUUIDString]];
    [result setServiceUuid:[descriptor.characteristic.service.UUID fullUUIDString]];
    int value = [descriptor.value intValue];
    [result setValue:[NSData dataWithBytes:&value length:sizeof(value)]];
    return result;
}

- (ProtosCharacteristicProperties *)toCharacteristicPropsProto:(CBCharacteristicProperties)props
{
    ProtosCharacteristicProperties *result = [[ProtosCharacteristicProperties alloc] init];
    [result setBroadcast:(props & CBCharacteristicPropertyBroadcast) != 0];
    [result setRead:(props & CBCharacteristicPropertyRead) != 0];
    [result setWriteWithoutResponse:(props & CBCharacteristicPropertyWriteWithoutResponse) != 0];
    [result setWrite:(props & CBCharacteristicPropertyWrite) != 0];
    [result setNotify:(props & CBCharacteristicPropertyNotify) != 0];
    [result setIndicate:(props & CBCharacteristicPropertyIndicate) != 0];
    [result setAuthenticatedSignedWrites:(props & CBCharacteristicPropertyAuthenticatedSignedWrites) != 0];
    [result setExtendedProperties:(props & CBCharacteristicPropertyExtendedProperties) != 0];
    [result setNotifyEncryptionRequired:(props & CBCharacteristicPropertyNotifyEncryptionRequired) != 0];
    [result setIndicateEncryptionRequired:(props & CBCharacteristicPropertyIndicateEncryptionRequired) != 0];
    return result;
}

- (ProtosMtuSizeResponse *)toMtuSizeResponseProto:(CBPeripheral *)peripheral mtu:(uint32_t)mtu
{
    ProtosMtuSizeResponse *result = [[ProtosMtuSizeResponse alloc] init];
    [result setRemoteId:[[peripheral identifier] UUIDString]];
    [result setMtu:mtu];
    return result;
}

- (void)log:(LogLevel)level format:(NSString *)format, ...
{
    if (level <= _logLevel)
    {
        va_list args;
        va_start(args, format);
        //    NSString* formattedMessage = [[NSString alloc] initWithFormat:format arguments:args];
        NSLog(format, args);
        va_end(args);
    }
}

- (uint32_t)getMtu:(CBPeripheral *)peripheral
{
    if (@available(iOS 9.0, *))
    {
        // Which type should we use? (issue #365)
        return (uint32_t)[peripheral maximumWriteValueLengthForType:CBCharacteristicWriteWithoutResponse];
    }
    else
    {
        // Fallback to minimum on earlier versions. (issue #364)
        return 20;
    }
}

@end

@implementation FlutterBluePlusStreamHandler

- (FlutterError *)onListenWithArguments:(id)arguments eventSink:(FlutterEventSink)eventSink
{
    self.sink = eventSink;
    if (self.cachedBluetoothState != nil)
    {
        self.sink(self.cachedBluetoothState);
    }
    return nil;
}

- (FlutterError *)onCancelWithArguments:(id)arguments
{
    self.sink = nil;
    return nil;
}

@end
